开了 React Compiler 之后:那些被 ESLint 拦下的 React Native 代码
2026-06-17 22:37
以前我们手写
useMemo、useCallback来“讨好”React 的渲染机制;React Compiler 想把这件事彻底接管。代价是:它要求你的代码足够“干净、可预测”——而它强制你做到这一点的方式,就是一连串看起来很烦的 ESLint 报错。
最近在一个 Expo / React Native 项目里,我把 app.json 的 experiments.reactCompiler 打开之后,编辑器里突然冒出一大堆红线。这些报错乍看像是 TypeScript 类型错误,点进去才发现全是 eslint-plugin-react-hooks 的新规则。
折腾下来我意识到:这些报错不是来找茬的,它们每一条都在把你往“React Compiler 能安全优化的写法”上推。这篇就把我踩到的三个典型报错拆开讲讲——它们分别对应一种很常见的写法,理解了原理,以后看到就知道该怎么改。
先搞清楚 React Compiler 在干嘛
传统 React 里,组件每次渲染都会重新执行函数体:重新创建对象、重新定义闭包、重新计算派生值。大部分时候这没问题,但一旦某个子组件用 React.memo 包了、或者某个值进了 useEffect 的依赖数组,这种“每次都是新引用”就会触发不必要的重渲染。
于是我们手动优化:
JSXconst handleClick = useCallback(() => doSomething(id), [id]); const sorted = useMemo(() => list.sort(cmp), [list]);
写多了你会发现,这其实是一种机械劳动——你在替编译器做本该自动完成的缓存判断,还经常写错依赖数组。
React Compiler(早期代号 Forget) 就是来接管这件事的。它是一个编译期工具(跑在 Babel/SWC 阶段),会分析你的组件,自动推断“哪个值依赖哪些输入、什么时候需要重算”,然后生成等价的、带缓存的代码。理想情况下,你再也不用手写 useMemo / useCallback。
但它能这么做有个前提:你的组件必须符合 React 的规则——渲染是纯函数、副作用待在该待的地方、数据流可预测。否则编译器没法安全地推断缓存边界。
为了强制这个前提,新版 eslint-plugin-react-hooks(v6)带来了一批配套规则。我遇到的报错,全都出自这里。
报错一:别用 Effect 去“同步”派生状态
第一个红线出现在一个 VIP 订阅页。代码大概长这样:
JSXconst [tiers, setTiers] = useState(INITIAL_TIERS); // 从 IAP 拿到订阅信息后,把价格写回 tiers useEffect(() => { for (const sub of subscriptions) { if (sub.displayName === "Plus") { setTiers((pre) => pre.map((t) => (t.id === "plus" ? { ...t, price: `${sub.price}` } : t)) ); } } }, [subscriptions]);
报错:
react-hooks/set-state-in-effect
Calling setState synchronously within an effect can trigger cascading renders
为什么这是反模式
关键在于:tiers 根本不是一个“独立状态”,它是 subscriptions 算出来的派生值。
用 effect 同步派生值,会走这样一条路径:
subscriptions 变化
→ 组件渲染一次(此时 tiers 还是旧的)
→ effect 执行 → setTiers
→ 组件又渲染一次(tiers 这才更新)
一次数据变化触发了两次渲染,中间还闪过一帧旧数据。这就是所谓的级联渲染(cascading render)。React 官方那篇 You Might Not Need an Effect 专门讲这件事——其实和 React Compiler 无关,是 React 一直以来的建议,只是 Compiler 把它升级成了硬性规则(因为级联渲染会让它的优化前提失效)。
正确写法:渲染期直接算
既然 tiers 是派生的,那就别存进 state,直接在渲染期算出来:
JSXconst priceMap = {}; for (const sub of subscriptions) { if (sub.displayName === "Plus") priceMap["plus"] = `${sub.price}`; } const tiers = INITIAL_TIERS.map((t) => priceMap[t.id] != null ? { ...t, price: priceMap[t.id] } : t );
subscriptions 一变,下次渲染时 tiers 自然就是新值。一次渲染搞定,没有中间态,也没有第二个 state 需要维护。
报错二:开了 Compiler 就别再手写 useMemo
改完上面那段,我下意识地给派生计算包了个 useMemo(毕竟有个 for 循环,感觉应该缓存一下):
JSXconst tiers = useMemo(() => { const priceMap = {}; for (const sub of subscriptions) { /* ... */ } return INITIAL_TIERS.map(/* ... */); }, [subscriptions]);
结果又红了:
react-hooks/preserve-manual-memoization
Existing memoization could not be preserved
React Compiler has skipped optimizing this component
为什么手写 useMemo 反而坏事
这条报错背后的逻辑很有意思。React Compiler 看到你手写的 useMemo,它不会简单地无视,而是要先验证你的依赖数组写得对不对,再用它自己的缓存机制把这段替换掉。
但它有个保守的底线:如果它没法 100% 复刻你手写记忆化的行为,它宁可放弃优化整个组件,并报错提醒你。我那个 useMemo 里有 for 循环加上不断 mutate 一个 priceMap 对象,编译器分析依赖关系时觉得“我没把握复刻得一模一样”,于是直接跳过。
也就是说:一个写得不够规整的手动 useMemo,会让整个组件失去自动优化——反而比不写还糟。
正确写法:删掉 useMemo,写普通 const
JSX// 不要 useMemo,普通计算即可,编译器会自动记忆化 const priceMap = {}; for (const sub of subscriptions) { /* ... */ } const tiers = INITIAL_TIERS.map(/* ... */);
这是开了 React Compiler 之后最需要扭转的直觉:绝大多数 useMemo / useCallback 都不该再手写了。你只管把计算写成干净的纯函数,缓存交给编译器。手写记忆化从“优化手段”变成了“可能挡路的东西”。
当然也有例外:如果某个值要传给一个没被 Compiler 编译的第三方组件、或者依赖项里有 Compiler 无法追踪的外部可变量,手写记忆化仍有意义。但默认心态应该是“先不写”。
报错三:和 Reanimated 的“可变”设计正面冲突
前两个报错都是我的写法确实有问题。但第三个不一样——它是 React Compiler 的假设和某个库的设计真冲突了。
场景是一个用 Reanimated 写的自定义下拉刷新组件。Reanimated 的核心是 useSharedValue,它返回一个对象,你通过给 .value 赋值来驱动动画:
JSXconst pullDownPosition = useSharedValue(0); // 在手势回调里直接改 .value,这是 Reanimated 的标准用法 pullDownPosition.value = withTiming(0, { duration: 180 });
打开 Compiler 后,几乎每一处 .value = 赋值都被标红:
react-hooks/immutability
This value cannot be modified
Modifying a value previously passed as an argument to a hook is not allowed
为什么这次是“误报”
React Compiler 的优化建立在一个假设上:被 hook 引用过的值是不可变的(这样它才能安全地缓存)。于是它把 useSharedValue 返回的对象也当成不可变的,看到 .value = 赋值就判定为“非法修改”。
可问题是,Reanimated 的 shared value 生来就是要被 .value = 修改的——这是它在 UI 线程驱动动画的整个工作方式。两边的世界观直接对撞了。
我还顺手验证了一下:这条规则和 react-hooks/exhaustive-deps 甚至是互相打架的——一个要求你把 shared value 放进依赖数组,另一个又禁止你修改“放进了依赖数组的值”。怎么改都满足不了。
处理方式:针对性关掉这条规则
既然是库设计层面的不兼容、且是误报,最干净的做法就是在 ESLint 配置里关掉这一条(其余 Compiler 规则全部保留):
JS// eslint.config.js module.exports = defineConfig([ expoConfig, { rules: { // Reanimated 的 shared value 通过 `.value =` 修改是其设计模式, // 但 React Compiler 的 immutability 规则会把这些赋值误报为非法修改, // 与 Reanimated 根本冲突,故全局关闭该条规则。 "react-hooks/immutability": "off", }, }, ]);
这里的判断很重要:前两个报错是“我写错了,按规则改”;这一个是“规则错了,关掉它”。区分这两种情况,靠的是理解每条规则到底在保护什么——如果它保护的前提(不可变)在你的场景里本就不成立,那它就是误报。
小结:一张表 + 一句心法
把这三条(外加常见的 exhaustive-deps)放一起看:
| 规则 | 它在管什么 | 该怎么应对 |
|---|---|---|
set-state-in-effect | 别用 effect 同步派生状态 | 渲染期直接算 |
preserve-manual-memoization | 开了 Compiler 就别手写 useMemo | 删掉,写普通 const |
immutability | 误判 Reanimated 的 .value = | 对 Reanimated 关掉该规则 |
exhaustive-deps | 依赖数组要写全(仅 warning) | 视情况补依赖 |
这几条报错背后其实是同一件事:React Compiler 把性能优化从“你的工作”变成了“它的工作”,作为交换,它要求你的代码足够纯粹、可预测。
所以心法很简单——开了 Compiler 之后,你的注意力应该从“我要不要在这里加个 useMemo”转移到:
- 这个值是不是派生的?是的话别存 state,直接算。
- 这个副作用真的需要 effect 吗?还是渲染期就能完成?
- 这个报错是我写错了,还是某个库的设计和 Compiler 的假设冲突了?
把代码写干净,优化交给编译器。这大概就是 React Compiler 时代写组件的新默认姿势。